// globalConfig.js
		// ============================================================================
		// ============================================================================
		
		// Provides global variables used by the entire program.
		// Most of this should be configuration.
		
		// Timing multiplier for entire game engine.
		let gameSpeed = 1;
		
		// Colors
		const BLUE =   { r: 0x67, g: 0xd7, b: 0xf0 };
		const GREEN =  { r: 0xa6, g: 0xe0, b: 0x2c };
		const PINK =   { r: 0xfa, g: 0x24, b: 0x73 };
		const ORANGE = { r: 0xfe, g: 0x95, b: 0x22 };
		const allColors = [BLUE, GREEN, PINK, ORANGE];
		
		// Gameplay
		const getSpawnDelay = () => {
			const spawnDelayMax = 1400;
			const spawnDelayMin = 550;
			const spawnDelay = spawnDelayMax - state.game.cubeCount * 3.1;
			return Math.max(spawnDelay, spawnDelayMin);
		}
		const doubleStrongEnableScore = 2000;
		// Number of cubes that must be smashed before activating a feature.
		const slowmoThreshold = 10;
		const strongThreshold = 25;
		const spinnerThreshold = 25;
		
		// Interaction state
		let pointerIsDown = false;
		// The last known position of the primary pointer in screen coordinates.`
		let pointerScreen = { x: 0, y: 0 };
		// Same as `pointerScreen`, but converted to scene coordinates in rAF.
		let pointerScene = { x: 0, y: 0 };
		// Minimum speed of pointer before "hits" are counted.
		const minPointerSpeed = 60;
		// The hit speed affects the direction the target post-hit. This number dampens that force.
		const hitDampening = 0.1;
		// Backboard receives shadows and is the farthest negative Z position of entities.
		const backboardZ = -400;
		const shadowColor = '#262e36';
		// How much air drag is applied to standard objects
		const airDrag = 0.022;
		const gravity = 0.3;
		// Spark config
		const sparkColor = 'rgba(170,221,255,.9)';
		const sparkThickness = 2.2;
		const airDragSpark = 0.1;
		// Track pointer positions to show trail
		const touchTrailColor = 'rgba(170,221,255,.62)';
		const touchTrailThickness = 7;
		const touchPointLife = 120;
		const touchPoints = [];
		// Size of in-game targets. This affects rendered size and hit area.
		const targetRadius = 40;
		const targetHitRadius = 50;
		const makeTargetGlueColor = target => {
			// const alpha = (target.health - 1) / (target.maxHealth - 1);
			// return `rgba(170,221,255,${alpha.toFixed(3)})`;
			return 'rgb(170,221,255)';
		};
		// Size of target fragments
		const fragRadius = targetRadius / 3;
		
		
		
		// Game canvas element needed in setup.js and interaction.js
		const canvas = document.querySelector('#c');
		
		// 3D camera config
		// Affects perspective
		const cameraDistance = 900;
		// Does not affect perspective
		const sceneScale = 1;
		// Objects that get too close to the camera will be faded out to transparent over this range.
		// const cameraFadeStartZ = 0.8*cameraDistance - 6*targetRadius;
		const cameraFadeStartZ = 0.45*cameraDistance;
		const cameraFadeEndZ = 0.65*cameraDistance;
		const cameraFadeRange = cameraFadeEndZ - cameraFadeStartZ;
		
		// Globals used to accumlate all vertices/polygons in each frame
		const allVertices = [];
		const allPolys = [];
		const allShadowVertices = [];
		const allShadowPolys = [];
		
		
		
		
		// state.js
		// ============================================================================
		// ============================================================================
		
		///////////
		// Enums //
		///////////
		
		// Game Modes
		const GAME_MODE_RANKED = Symbol('GAME_MODE_RANKED');
		const GAME_MODE_CASUAL = Symbol('GAME_MODE_CASUAL');
		
		// Available Menus
		const MENU_MAIN = Symbol('MENU_MAIN');
		const MENU_PAUSE = Symbol('MENU_PAUSE');
		const MENU_SCORE = Symbol('MENU_SCORE');
		
		
		
		//////////////////
		// Global State //
		//////////////////
		
		const state = {
			game: {
				mode: GAME_MODE_RANKED,
				// Run time of current game.
				time: 0,
				// Player score.
				score: 0,
				// Total number of cubes smashed in game.
				cubeCount: 0
			},
			menus: {
				// Set to `null` to hide all menus
				active: MENU_MAIN
			}
		};
		
		
		////////////////////////////
		// Global State Selectors //
		////////////////////////////
		
		const isInGame = () => !state.menus.active;
		const isMenuVisible = () => !!state.menus.active;
		const isCasualGame = () => state.game.mode === GAME_MODE_CASUAL;
		const isPaused = () => state.menus.active === MENU_PAUSE;
		
		
		///////////////////
		// Local Storage //
		///////////////////
		
		const highScoreKey = '__menja__highScore';
		const getHighScore = () => {
			const raw = localStorage.getItem(highScoreKey);
			return raw ? parseInt(raw, 10) : 0;
		};
		
		let _lastHighscore = getHighScore();
		const setHighScore = score => {
			_lastHighscore = getHighScore();
			localStorage.setItem(highScoreKey, String(score));
		};
		
		const isNewHighScore = () => state.game.score > _lastHighscore;
		
		
		
		
		// utils.js
		// ============================================================================
		// ============================================================================
		
		
		const invariant = (condition, message) => {
			if (!condition) throw new Error(message);
		};
		
		
		/////////
		// DOM //
		/////////
		
		const $ = selector => document.querySelector(selector);
		const handleClick = (element, handler) => element.addEventListener('click', handler);
		const handlePointerDown = (element, handler) => {
			element.addEventListener('touchstart', handler);
			element.addEventListener('mousedown', handler);
		};
		
		
		
		////////////////////////
		// Formatting Helpers //
		////////////////////////
		
		// Converts a number into a formatted string with thousand separators.
		const formatNumber = num => num.toLocaleString();
		
		
		
		////////////////////
		// Math Constants //
		////////////////////
		
		const PI = Math.PI;
		const TAU = Math.PI * 2;
		const ETA = Math.PI * 0.5;
		
		
		
		//////////////////
		// Math Helpers //
		//////////////////
		
		// Clamps a number between min and max values (inclusive)
		const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
		
		// Linearly interpolate between numbers a and b by a specific amount.
		// mix >= 0 && mix <= 1
		const lerp = (a, b, mix) => (b - a) * mix + a;
		
		
		
		
		////////////////////
		// Random Helpers //
		////////////////////
		
		// Generates a random number between min (inclusive) and max (exclusive)
		const random = (min, max) => Math.random() * (max - min) + min;
		
		// Generates a random integer between and possibly including min and max values
		const randomInt = (min, max) => ((Math.random() * (max - min + 1)) | 0) + min;
		
		// Returns a random element from an array
		const pickOne = arr => arr[Math.random() * arr.length | 0];
		
		
		
		
		///////////////////
		// Color Helpers //
		///////////////////
		
		// Converts an { r, g, b } color object to a 6-digit hex code.
		const colorToHex = color => {
			return '#' +
				(color.r | 0).toString(16).padStart(2, '0') +
				(color.g | 0).toString(16).padStart(2, '0') +
				(color.b | 0).toString(16).padStart(2, '0');
		};
		
		// Operates on an { r, g, b } color object.
		// Returns string hex code.
		// `lightness` must range from 0 to 1. 0 is pure black, 1 is pure white.
		const shadeColor = (color, lightness) => {
			let other, mix;
			if (lightness < 0.5) {
				other = 0;
				mix = 1 - (lightness * 2);
			} else {
				other = 255;
				mix = lightness * 2 - 1;
			}
			return '#' +
				(lerp(color.r, other, mix) | 0).toString(16).padStart(2, '0') +
				(lerp(color.g, other, mix) | 0).toString(16).padStart(2, '0') +
				(lerp(color.b, other, mix) | 0).toString(16).padStart(2, '0');
		};
		
		
		
		
		
		////////////////////
		// Timing Helpers //
		////////////////////
		
		const _allCooldowns = [];
		
		const makeCooldown = (rechargeTime, units=1) => {
			let timeRemaining = 0;
			let lastTime = 0;
		
			const initialOptions = { rechargeTime, units };
		
			const updateTime = () => {
				const now = state.game.time;
				// Reset time remaining if time goes backwards.
				if (now < lastTime) {
					timeRemaining = 0;
				} else {
					// update...
					timeRemaining -= now-lastTime;
					if (timeRemaining < 0) timeRemaining = 0;
				}
				lastTime = now;
			};
		
			const canUse = () => {
				updateTime();
				return timeRemaining <= (rechargeTime * (units-1));
			};
		
			const cooldown = {
				canUse,
				useIfAble() {
					const usable = canUse();
					if (usable) timeRemaining += rechargeTime;
					return usable;
				},
				mutate(options) {
					if (options.rechargeTime) {
						// Apply recharge time delta so change takes effect immediately.
						timeRemaining -= rechargeTime-options.rechargeTime;
						if (timeRemaining < 0) timeRemaining = 0;
						rechargeTime = options.rechargeTime;
					}
					if (options.units) units = options.units;
				},
				reset() {
					timeRemaining = 0;
					lastTime = 0;
					this.mutate(initialOptions);
				}
			};
		
			_allCooldowns.push(cooldown);
		
			return cooldown;
		};
		
		const resetAllCooldowns = () => _allCooldowns.forEach(cooldown => cooldown.reset());
		
		const makeSpawner = ({ chance, cooldownPerSpawn, maxSpawns }) => {
			const cooldown = makeCooldown(cooldownPerSpawn, maxSpawns);
			return {
				shouldSpawn() {
					return Math.random() <= chance && cooldown.useIfAble();
				},
				mutate(options) {
					if (options.chance) chance = options.chance;
					cooldown.mutate({
						rechargeTime: options.cooldownPerSpawn,
						units: options.maxSpawns
					});
				}
			};
		};
		
		
		
		
		////////////////////
		// Vector Helpers //
		////////////////////
		
		const normalize = v => {
			const mag = Math.hypot(v.x, v.y, v.z);
			return {
				x: v.x / mag,
				y: v.y / mag,
				z: v.z / mag
			};
		}
		
		// Curried math helpers
		const add = a => b => a + b;
		// Curried vector helpers
		const scaleVector = scale => vector => {
			vector.x *= scale;
			vector.y *= scale;
			vector.z *= scale;
		};
		
		
		
		
		
		
		
		
		////////////////
		// 3D Helpers //
		////////////////
		
		// Clone array and all vertices.
		function cloneVertices(vertices) {
			return vertices.map(v => ({ x: v.x, y: v.y, z: v.z }));
		}
		
		// Copy vertex data from one array into another.
		// Arrays must be the same length.
		function copyVerticesTo(arr1, arr2) {
			const len = arr1.length;
			for (let i=0; i<len; i++) {
				const v1 = arr1[i];
				const v2 = arr2[i];
				v2.x = v1.x;
				v2.y = v1.y;
				v2.z = v1.z;
			}
		}
		
		// Compute triangle midpoint.
		// Mutates `middle` property of given `poly`.
		function computeTriMiddle(poly) {
			const v = poly.vertices;
			poly.middle.x = (v[0].x + v[1].x + v[2].x) / 3;
			poly.middle.y = (v[0].y + v[1].y + v[2].y) / 3;
			poly.middle.z = (v[0].z + v[1].z + v[2].z) / 3;
		}
		
		// Compute quad midpoint.
		// Mutates `middle` property of given `poly`.
		function computeQuadMiddle(poly) {
			const v = poly.vertices;
			poly.middle.x = (v[0].x + v[1].x + v[2].x + v[3].x) / 4;
			poly.middle.y = (v[0].y + v[1].y + v[2].y + v[3].y) / 4;
			poly.middle.z = (v[0].z + v[1].z + v[2].z + v[3].z) / 4;
		}
		
		function computePolyMiddle(poly) {
			if (poly.vertices.length === 3) {
				computeTriMiddle(poly);
			} else {
				computeQuadMiddle(poly);
			}
		}
		
		// Compute distance from any polygon (tri or quad) midpoint to camera.
		// Sets `depth` property of given `poly`.
		// Also triggers midpoint calculation, which mutates `middle` property of `poly`.
		function computePolyDepth(poly) {
			computePolyMiddle(poly);
			const dX = poly.middle.x;
			const dY = poly.middle.y;
			const dZ = poly.middle.z - cameraDistance;
			poly.depth = Math.hypot(dX, dY, dZ);
		}
		
		// Compute normal of any polygon. Uses normalized vector cross product.
		// Mutates `normalName` property of given `poly`.
		function computePolyNormal(poly, normalName) {
			// Store quick refs to vertices
			const v1 = poly.vertices[0];
			const v2 = poly.vertices[1];
			const v3 = poly.vertices[2];
			// Calculate difference of vertices, following winding order.
			const ax = v1.x - v2.x;
			const ay = v1.y - v2.y;
			const az = v1.z - v2.z;
			const bx = v1.x - v3.x;
			const by = v1.y - v3.y;
			const bz = v1.z - v3.z;
			// Cross product
			const nx = ay*bz - az*by;
			const ny = az*bx - ax*bz;
			const nz = ax*by - ay*bx;
			// Compute magnitude of normal and normalize
			const mag = Math.hypot(nx, ny, nz);
			const polyNormal = poly[normalName];
			polyNormal.x = nx / mag;
			polyNormal.y = ny / mag;
			polyNormal.z = nz / mag;
		}
		
		// Apply translation/rotation/scale to all given vertices.
		// If `vertices` and `target` are the same array, the vertices will be mutated in place.
		// If `vertices` and `target` are different arrays, `vertices` will not be touched, instead the
		// transformed values from `vertices` will be written to `target` array.
		function transformVertices(vertices, target, tX, tY, tZ, rX, rY, rZ, sX, sY, sZ) {
			// Matrix multiplcation constants only need calculated once for all vertices.
			const sinX = Math.sin(rX);
			const cosX = Math.cos(rX);
			const sinY = Math.sin(rY);
			const cosY = Math.cos(rY);
			const sinZ = Math.sin(rZ);
			const cosZ = Math.cos(rZ);
		
			// Using forEach() like map(), but with a (recycled) target array.
			vertices.forEach((v, i) => {
				const targetVertex = target[i];
				// X axis rotation
				const x1 = v.x;
				const y1 = v.z*sinX + v.y*cosX;
				const z1 = v.z*cosX - v.y*sinX;
				// Y axis rotation
				const x2 = x1*cosY - z1*sinY;
				const y2 = y1;
				const z2 = x1*sinY + z1*cosY;
				// Z axis rotation
				const x3 = x2*cosZ - y2*sinZ;
				const y3 = x2*sinZ + y2*cosZ;
				const z3 = z2;
		
				// Scale, Translate, and set the transform.
				targetVertex.x = x3 * sX + tX;
				targetVertex.y = y3 * sY + tY;
				targetVertex.z = z3 * sZ + tZ;
			});
		}
		
		// 3D projection on a single vertex.
		// Directly mutates the vertex.
		const projectVertex = v => {
			const focalLength = cameraDistance * sceneScale;
			const depth = focalLength / (cameraDistance - v.z);
			v.x = v.x * depth;
			v.y = v.y * depth;
		};
		
		// 3D projection on a single vertex.
		// Mutates a secondary target vertex.
		const projectVertexTo = (v, target) => {
			const focalLength = cameraDistance * sceneScale;
			const depth = focalLength / (cameraDistance - v.z);
			target.x = v.x * depth;
			target.y = v.y * depth;
		};
		
		
		
		
		
		// PERF.js
		// ============================================================================
		// ============================================================================
		
		// Dummy no-op functions.
		// I use these in a special build for custom performance profiling.
		const PERF_START = () => {};
		const PERF_END = () => {};
		const PERF_UPDATE = () => {};
		
		
		
		
		// 3dModels.js
		// ============================================================================
		// ============================================================================
		
		// Define models once. The origin is the center of the model.
		
		// A simple cube, 8 vertices, 6 quads.
		// Defaults to an edge length of 2 units, can be influenced with `scale`.
		function makeCubeModel({ scale=1 }) {
			return {
				vertices: [
					// top
					{ x: -scale, y: -scale, z: scale },
					{ x:  scale, y: -scale, z: scale },
					{ x:  scale, y:  scale, z: scale },
					{ x: -scale, y:  scale, z: scale },
					// bottom
					{ x: -scale, y: -scale, z: -scale },
					{ x:  scale, y: -scale, z: -scale },
					{ x:  scale, y:  scale, z: -scale },
					{ x: -scale, y:  scale, z: -scale }
				],
				polys: [
					// z = 1
					{ vIndexes: [0, 1, 2, 3] },
					// z = -1
					{ vIndexes: [7, 6, 5, 4] },
					// y = 1
					{ vIndexes: [3, 2, 6, 7] },
					// y = -1
					{ vIndexes: [4, 5, 1, 0] },
					// x = 1
					{ vIndexes: [5, 6, 2, 1] },
					// x = -1
					{ vIndexes: [0, 3, 7, 4] }
				]
			};
		}
		
		// Not very optimized - lots of duplicate vertices are generated.
		function makeRecursiveCubeModel({ recursionLevel, splitFn, color, scale=1 }) {
			const getScaleAtLevel = level => 1 / (3 ** level);
		
			// We can model level 0 manually. It's just a single, centered, cube.
			let cubeOrigins = [{ x: 0, y: 0, z: 0 }];
		
			// Recursively replace cubes with smaller cubes.
			for (let i=1; i<=recursionLevel; i++) {
				const scale = getScaleAtLevel(i) * 2;
				const cubeOrigins2 = [];
				cubeOrigins.forEach(origin => {
					cubeOrigins2.push(...splitFn(origin, scale));
				});
				cubeOrigins = cubeOrigins2;
			}
		
			const finalModel = { vertices: [], polys: [] };
		
			// Generate single cube model and scale it.
			const cubeModel = makeCubeModel({ scale: 1 });
			cubeModel.vertices.forEach(scaleVector(getScaleAtLevel(recursionLevel)));
		
			// Compute the max distance x, y, or z origin values will be.
			// Same result as `Math.max(...cubeOrigins.map(o => o.x))`, but much faster.
			const maxComponent = getScaleAtLevel(recursionLevel) * (3 ** recursionLevel - 1);
		
			// Place cube geometry at each origin.
			cubeOrigins.forEach((origin, cubeIndex) => {
				// To compute occlusion (shading), find origin component with greatest
				// magnitude and normalize it relative to `maxComponent`.
				const occlusion = Math.max(
					Math.abs(origin.x),
					Math.abs(origin.y),
					Math.abs(origin.z)
				) / maxComponent;
				// At lower iterations, occlusion looks better lightened up a bit.
				const occlusionLighter = recursionLevel > 2
					? occlusion
					: (occlusion + 0.8) / 1.8;
				// Clone, translate vertices to origin, and apply scale
				finalModel.vertices.push(
					...cubeModel.vertices.map(v => ({
						x: (v.x + origin.x) * scale,
						y: (v.y + origin.y) * scale,
						z: (v.z + origin.z) * scale
					}))
				);
				// Clone polys, shift referenced vertex indexes, and compute color.
				finalModel.polys.push(
					...cubeModel.polys.map(poly => ({
						vIndexes: poly.vIndexes.map(add(cubeIndex * 8))
					}))
				);
			});
		
			return finalModel;
		}
		
		
		// o: Vector3D - Position of cube's origin (center).
		// s: Vector3D - Determines size of menger sponge.
		function mengerSpongeSplit(o, s) {
			return [
				// Top
				{ x: o.x + s, y: o.y - s, z: o.z + s },
				{ x: o.x + s, y: o.y - s, z: o.z + 0 },
				{ x: o.x + s, y: o.y - s, z: o.z - s },
				{ x: o.x + 0, y: o.y - s, z: o.z + s },
				{ x: o.x + 0, y: o.y - s, z: o.z - s },
				{ x: o.x - s, y: o.y - s, z: o.z + s },
				{ x: o.x - s, y: o.y - s, z: o.z + 0 },
				{ x: o.x - s, y: o.y - s, z: o.z - s },
				// Bottom
				{ x: o.x + s, y: o.y + s, z: o.z + s },
				{ x: o.x + s, y: o.y + s, z: o.z + 0 },
				{ x: o.x + s, y: o.y + s, z: o.z - s },
				{ x: o.x + 0, y: o.y + s, z: o.z + s },
				{ x: o.x + 0, y: o.y + s, z: o.z - s },
				{ x: o.x - s, y: o.y + s, z: o.z + s },
				{ x: o.x - s, y: o.y + s, z: o.z + 0 },
				{ x: o.x - s, y: o.y + s, z: o.z - s },
				// Middle
				{ x: o.x + s, y: o.y + 0, z: o.z + s },
				{ x: o.x + s, y: o.y + 0, z: o.z - s },
				{ x: o.x - s, y: o.y + 0, z: o.z + s },
				{ x: o.x - s, y: o.y + 0, z: o.z - s }
			];
		}
		
		
		
		// Helper to optimize models by merging duplicate vertices within a threshold,
		// and removing all polys that share the same vertices.
		// Directly mutates the model.
		function optimizeModel(model, threshold=0.0001) {
			const { vertices, polys } = model;
		
			const compareVertices = (v1, v2) => (
				Math.abs(v1.x - v2.x) < threshold &&
				Math.abs(v1.y - v2.y) < threshold &&
				Math.abs(v1.z - v2.z) < threshold
			);
		
			const comparePolys = (p1, p2) => {
				const v1 = p1.vIndexes;
				const v2 = p2.vIndexes;
				return (
					(
						v1[0] === v2[0] ||
						v1[0] === v2[1] ||
						v1[0] === v2[2] ||
						v1[0] === v2[3]
					) && (
						v1[1] === v2[0] ||
						v1[1] === v2[1] ||
						v1[1] === v2[2] ||
						v1[1] === v2[3]
					) && (
						v1[2] === v2[0] ||
						v1[2] === v2[1] ||
						v1[2] === v2[2] ||
						v1[2] === v2[3]
					) && (
						v1[3] === v2[0] ||
						v1[3] === v2[1] ||
						v1[3] === v2[2] ||
						v1[3] === v2[3]
					)
				);
			};
		
		
			vertices.forEach((v, i) => {
				v.originalIndexes = [i];
			});
		
			for (let i=vertices.length-1; i>=0; i--) {
				for (let ii=i-1; ii>=0; ii--) {
					const v1 = vertices[i];
					const v2 = vertices[ii];
					if (compareVertices(v1, v2)) {
						vertices.splice(i, 1);
						v2.originalIndexes.push(...v1.originalIndexes);
						break;
					}
				}
			}
		
			vertices.forEach((v, i) => {
				polys.forEach(p => {
					p.vIndexes.forEach((vi, ii, arr) => {
						const vo = v.originalIndexes;
						if (vo.includes(vi)) {
							arr[ii] = i;
						}
					});
				});
			});
		
			polys.forEach(p => {
				const vi = p.vIndexes;
				p.sum = vi[0] + vi[1] + vi[2] + vi[3];
			});
			polys.sort((a, b) => b.sum - a.sum);
		
			// Assumptions:
			// 1. Each poly will either have no duplicates or 1 duplicate.
			// 2. If two polys are equal, they are both hidden (two cubes touching),
			//    therefore both can be removed.
			for (let i=polys.length-1; i>=0; i--) {
				for (let ii=i-1; ii>=0; ii--) {
					const p1 = polys[i];
					const p2 = polys[ii];
					if (p1.sum !== p2.sum) break;
					if (comparePolys(p1, p2)) {
						polys.splice(i, 1);
						polys.splice(ii, 1);
						i--;
						break;
					}
				}
			}
		
			return model;
		}
		
		
		
		
		
		// Entity.js
		// ============================================================================
		// ============================================================================
		
		class Entity {
			constructor({ model, color, wireframe=false }) {
				const vertices = cloneVertices(model.vertices);
				const shadowVertices = cloneVertices(model.vertices);
				const colorHex = colorToHex(color);
				const darkColorHex = shadeColor(color, 0.4);
		
				const polys = model.polys.map(p => ({
					vertices: p.vIndexes.map(vIndex => vertices[vIndex]),
					color: color, // custom rgb color object
					wireframe: wireframe,
					strokeWidth: wireframe ? 2 : 0, // Set to non-zero value to draw stroke
					strokeColor: colorHex, // must be a CSS color string
					strokeColorDark: darkColorHex, // must be a CSS color string
					depth: 0,
					middle: { x: 0, y: 0, z: 0 },
					normalWorld: { x: 0, y: 0, z: 0 },
					normalCamera: { x: 0, y: 0, z: 0 }
				}));
		
				const shadowPolys = model.polys.map(p => ({
					vertices: p.vIndexes.map(vIndex => shadowVertices[vIndex]),
					wireframe: wireframe,
					normalWorld: { x: 0, y: 0, z: 0 }
				}));
		
				this.projected = {}; // Will store 2D projected data
				this.model = model;
				this.vertices = vertices;
				this.polys = polys;
				this.shadowVertices = shadowVertices;
				this.shadowPolys = shadowPolys;
				this.reset();
			}
		
			// Better names: resetEntity, resetTransform, resetEntityTransform
			reset() {
				this.x = 0;
				this.y = 0;
				this.z = 0;
				this.xD = 0;
				this.yD = 0;
				this.zD = 0;
		
				this.rotateX = 0;
				this.rotateY = 0;
				this.rotateZ = 0;
				this.rotateXD = 0;
				this.rotateYD = 0;
				this.rotateZD = 0;
		
				this.scaleX = 1;
				this.scaleY = 1;
				this.scaleZ = 1;
		
				this.projected.x = 0;
				this.projected.y = 0;
			}
		
			transform() {
				transformVertices(
					this.model.vertices,
					this.vertices,
					this.x,
					this.y,
					this.z,
					this.rotateX,
					this.rotateY,
					this.rotateZ,
					this.scaleX,
					this.scaleY,
					this.scaleZ
				);
		
				copyVerticesTo(this.vertices, this.shadowVertices);
			}
		
			// Projects origin point, stored as `projected` property.
			project() {
				projectVertexTo(this, this.projected);
			}
		}
		
		
		
		
		
		// getTarget.js
		// ============================================================================
		// ============================================================================
		
		// All active targets
		const targets = [];
		
		// Pool target instances by color, using a Map.
		// keys are color objects, and values are arrays of targets.
		// Also pool wireframe instances separately.
		const targetPool = new Map(allColors.map(c=>([c, []])));
		const targetWireframePool = new Map(allColors.map(c=>([c, []])));
		
		
		
		const getTarget = (() => {
		
			const slowmoSpawner = makeSpawner({
				chance: 0.5,
				cooldownPerSpawn: 10000,
				maxSpawns: 1
			});
		
			let doubleStrong = false;
			const strongSpawner = makeSpawner({
				chance: 0.3,
				cooldownPerSpawn: 12000,
				maxSpawns: 1
			});
		
			const spinnerSpawner = makeSpawner({
				chance: 0.1,
				cooldownPerSpawn: 10000,
				maxSpawns: 1
			});
		
			// Cached array instances, no need to allocate every time.
			const axisOptions = [
				['x', 'y'],
				['y', 'z'],
				['z', 'x']
			];
		
			function getTargetOfStyle(color, wireframe) {
				const pool = wireframe ? targetWireframePool : targetPool;
				let target = pool.get(color).pop();
				if (!target) {
					target = new Entity({
						model: optimizeModel(makeRecursiveCubeModel({
							recursionLevel: 1,
							splitFn: mengerSpongeSplit,
							scale: targetRadius
						})),
						color: color,
						wireframe: wireframe
					});
		
					// Init any properties that will be used.
					// These will not be automatically reset when recycled.
					target.color = color;
					target.wireframe = wireframe;
					// Some properties don't have their final value yet.
					// Initialize with any value of the right type.
					target.hit = false;
					target.maxHealth = 0;
					target.health = 0;
				}
				return target;
			}
		
			return function getTarget() {
				if (doubleStrong && state.game.score <= doubleStrongEnableScore) {
					doubleStrong = false;
					// Spawner is reset automatically when game resets.
				} else if (!doubleStrong && state.game.score > doubleStrongEnableScore) {
					doubleStrong = true;
					strongSpawner.mutate({ maxSpawns: 2 });
				}
		
				// Target Parameters
				// --------------------------------
				let color = pickOne([BLUE, GREEN, ORANGE]);
				let wireframe = false;
				let health = 1;
				let maxHealth = 3;
				const spinner = state.game.cubeCount >= spinnerThreshold && isInGame() && spinnerSpawner.shouldSpawn();
		
				// Target Parameter Overrides
				// --------------------------------
				if (state.game.cubeCount >= slowmoThreshold && slowmoSpawner.shouldSpawn()) {
					color = BLUE;
					wireframe = true;
				}
				else if (state.game.cubeCount >= strongThreshold && strongSpawner.shouldSpawn()) {
					color = PINK;
					health = 3;
				}
		
				// Target Creation
				// --------------------------------
				const target = getTargetOfStyle(color, wireframe);
				target.hit = false;
				target.maxHealth = maxHealth;
				target.health = health;
				updateTargetHealth(target, 0);
		
				const spinSpeeds = [
					Math.random() * 0.1 - 0.05,
					Math.random() * 0.1 - 0.05
				];
		
				if (spinner) {
					// Ends up spinning a random axis
					spinSpeeds[0] = -0.25;
					spinSpeeds[1] = 0;
					target.rotateZ = random(0, TAU);
				}
		
				const axes = pickOne(axisOptions);
		
				spinSpeeds.forEach((spinSpeed, i) => {
					switch (axes[i]) {
						case 'x':
							target.rotateXD = spinSpeed;
							break;
						case 'y':
							target.rotateYD = spinSpeed;
							break;
						case 'z':
							target.rotateZD = spinSpeed;
							break;
					}
				});
		
				return target;
			}
		})();
		
		
		const updateTargetHealth = (target, healthDelta) => {
			target.health += healthDelta;
			// Only update stroke on non-wireframe targets.
			// Showing "glue" is a temporary attempt to display health. For now, there's
			// no reason to have wireframe targets with high health, so we're fine.
			if (!target.wireframe) {
				const strokeWidth = target.health - 1;
				const strokeColor = makeTargetGlueColor(target);
				for (let p of target.polys) {
					p.strokeWidth = strokeWidth;
					p.strokeColor = strokeColor;
				}
			}
		};
		
		
		const returnTarget = target => {
			target.reset();
			const pool = target.wireframe ? targetWireframePool : targetPool;
			pool.get(target.color).push(target);
		};
		
		
		function resetAllTargets() {
			while(targets.length) {
				returnTarget(targets.pop());
			}
		}
		
		
		
		
		
		// createBurst.js
		// ============================================================================
		// ============================================================================
		
		// Track all active fragments
		const frags = [];
		// Pool inactive fragments by color, using a Map.
		// keys are color objects, and values are arrays of fragments.
		// // Also pool wireframe instances separately.
		const fragPool = new Map(allColors.map(c=>([c, []])));
		const fragWireframePool = new Map(allColors.map(c=>([c, []])));
		
		
		const createBurst = (() => {
			// Precompute some private data to be reused for all bursts.
			const basePositions = mengerSpongeSplit({ x:0, y:0, z:0 }, fragRadius*2);
			const positions = cloneVertices(basePositions);
			const prevPositions = cloneVertices(basePositions);
			const velocities = cloneVertices(basePositions);
		
			const basePositionNormals = basePositions.map(normalize);
			const positionNormals = cloneVertices(basePositionNormals);
		
		
			const fragCount = basePositions.length;
		
			function getFragForTarget(target) {
				const pool = target.wireframe ? fragWireframePool : fragPool;
				let frag = pool.get(target.color).pop();
				if (!frag) {
					frag = new Entity({
						model: makeCubeModel({ scale: fragRadius }),
						color: target.color,
						wireframe: target.wireframe
					});
					frag.color = target.color;
					frag.wireframe = target.wireframe;
				}
				return frag;
			}
		
			return (target, force=1) => {
				// Calculate fragment positions, and what would have been the previous positions
				// when still a part of the larger target.
				transformVertices(
					basePositions, positions,
					target.x, target.y, target.z,
					target.rotateX, target.rotateY, target.rotateZ,
					1, 1, 1
				);
				transformVertices(
					basePositions, prevPositions,
					target.x - target.xD, target.y - target.yD, target.z - target.zD,
					target.rotateX - target.rotateXD, target.rotateY - target.rotateYD, target.rotateZ - target.rotateZD,
					1, 1, 1
				);
		
				// Compute velocity of each fragment, based on previous positions.
				// Will write to `velocities` array.
				for (let i=0; i<fragCount; i++) {
					const position = positions[i];
					const prevPosition = prevPositions[i];
					const velocity = velocities[i];
		
					velocity.x = position.x - prevPosition.x;
					velocity.y = position.y - prevPosition.y;
					velocity.z = position.z - prevPosition.z;
				}
		
		
		
				// Apply target rotation to normals
				transformVertices(
					basePositionNormals, positionNormals,
					0, 0, 0,
					target.rotateX, target.rotateY, target.rotateZ,
					1, 1, 1
				);
		
		
				for (let i=0; i<fragCount; i++) {
					const position = positions[i];
					const velocity = velocities[i];
					const normal = positionNormals[i];
		
					const frag = getFragForTarget(target);
		
					frag.x = position.x;
					frag.y = position.y;
					frag.z = position.z;
					frag.rotateX = target.rotateX;
					frag.rotateY = target.rotateY;
					frag.rotateZ = target.rotateZ;
		
		
					const burstSpeed = 2 * force;
					const randSpeed = 2 * force;
					const rotateScale = 0.015;
					frag.xD = velocity.x + (normal.x * burstSpeed) + (Math.random() * randSpeed);
					frag.yD = velocity.y + (normal.y * burstSpeed) + (Math.random() * randSpeed);
					frag.zD = velocity.z + (normal.z * burstSpeed) + (Math.random() * randSpeed);
					frag.rotateXD = frag.xD * rotateScale;
					frag.rotateYD = frag.yD * rotateScale;
					frag.rotateZD = frag.zD * rotateScale;
		
					frags.push(frag);
				};
			}
		})();
		
		
		const returnFrag = frag => {
			frag.reset();
			const pool = frag.wireframe ? fragWireframePool : fragPool;
			pool.get(frag.color).push(frag);
		};
		
		
		
		
		
		// sparks.js
		// ============================================================================
		// ============================================================================
		
		const sparks = [];
		const sparkPool = [];
		
		
		function addSpark(x, y, xD, yD) {
			const spark = sparkPool.pop() || {};
		
			spark.x = x + xD * 0.5;
			spark.y = y + yD * 0.5;
			spark.xD = xD;
			spark.yD = yD;
			spark.life = random(200, 300);
			spark.maxLife = spark.life;
		
			sparks.push(spark);
		
			return spark;
		}
		
		
		// Spherical spark burst
		function sparkBurst(x, y, count, maxSpeed) {
			const angleInc = TAU / count;
			for (let i=0; i<count; i++) {
				const angle = i * angleInc + angleInc * Math.random();
				const speed = (1 - Math.random() ** 3) * maxSpeed;
				addSpark(
					x,
					y,
					Math.sin(angle) * speed,
					Math.cos(angle) * speed
				);
			}
		}
		
		
		// Make a target "leak" sparks from all vertices.
		// This is used to create the effect of target glue "shedding".
		let glueShedVertices;
		function glueShedSparks(target) {
			if (!glueShedVertices) {
				glueShedVertices = cloneVertices(target.vertices);
			} else {
				copyVerticesTo(target.vertices, glueShedVertices);
			}
		
			glueShedVertices.forEach(v => {
				if (Math.random() < 0.4) {
					projectVertex(v);
					addSpark(
						v.x,
						v.y,
						random(-12, 12),
						random(-12, 12)
					);
				}
			});
		}
		
		function returnSpark(spark) {
			sparkPool.push(spark);
		}
		
		
		
		
		
		// hud.js
		// ============================================================================
		// ============================================================================
		
		const hudContainerNode = $('.hud');
		
		function setHudVisibility(visible) {
			if (visible) {
				hudContainerNode.style.display = 'block';
			} else {
				hudContainerNode.style.display = 'none';
			}
		}
		
		
		///////////
		// Score //
		///////////
		const scoreNode = $('.score-lbl');
		const cubeCountNode = $('.cube-count-lbl');
		
		function renderScoreHud() {
			if (isCasualGame()) {
				scoreNode.style.display = 'none';
				cubeCountNode.style.opacity = 1;
			} else {
				scoreNode.innerText = `分数: ${state.game.score}`;
				scoreNode.style.display = 'block';
				cubeCountNode.style.opacity = 0.65 ;
			}
			cubeCountNode.innerText = `方块粉碎: ${state.game.cubeCount}`;
		}
		
		renderScoreHud();
		
		
		//////////////////
		// Pause Button //
		//////////////////
		
		handlePointerDown($('.pause-btn'), () => pauseGame());
		
		
		////////////////////
		// Slow-Mo Status //
		////////////////////
		
		const slowmoNode = $('.slowmo');
		const slowmoBarNode = $('.slowmo__bar');
		
		function renderSlowmoStatus(percentRemaining) {
			slowmoNode.style.opacity = percentRemaining === 0 ? 0 : 1;
			slowmoBarNode.style.transform = `scaleX(${percentRemaining.toFixed(3)})`;
		}
		
		
		
		
		
		// menus.js
		// ============================================================================
		// ============================================================================
		
		// Top-level menu containers
		const menuContainerNode = $('.menus');
		const menuMainNode = $('.menu--main');
		const menuPauseNode = $('.menu--pause');
		const menuScoreNode = $('.menu--score');
		
		const finalScoreLblNode = $('.final-score-lbl');
		const highScoreLblNode = $('.high-score-lbl');
		
		
		
		function showMenu(node) {
			node.classList.add('active');
		}
		
		function hideMenu(node) {
			node.classList.remove('active');
		}
		
		function renderMenus() {
			hideMenu(menuMainNode);
			hideMenu(menuPauseNode);
			hideMenu(menuScoreNode);
		
			switch (state.menus.active) {
				case MENU_MAIN:
					showMenu(menuMainNode);
					break;
				case MENU_PAUSE:
					showMenu(menuPauseNode);
					break;
				case MENU_SCORE:
					finalScoreLblNode.textContent = formatNumber(state.game.score);
					if (isNewHighScore()) {
						highScoreLblNode.textContent = '新的最高分!';
					} else {
						highScoreLblNode.textContent = `最高分: ${formatNumber(getHighScore())}`;
					}
					showMenu(menuScoreNode);
					break;
			}
		
			setHudVisibility(!isMenuVisible());
			menuContainerNode.classList.toggle('has-active', isMenuVisible());
			menuContainerNode.classList.toggle('interactive-mode', isMenuVisible() && pointerIsDown);
		}
		
		renderMenus();
		
		
		
		////////////////////
		// Button Actions //
		////////////////////
		
		// Main Menu
		handleClick($('.play-normal-btn'), () => {
			setGameMode(GAME_MODE_RANKED);
			setActiveMenu(null);
			resetGame();
		});
		
		handleClick($('.play-casual-btn'), () => {
			setGameMode(GAME_MODE_CASUAL);
			setActiveMenu(null);
			resetGame();
		});
		
		// Pause Menu
		handleClick($('.resume-btn'), () => resumeGame());
		handleClick($('.menu-btn--pause'), () => setActiveMenu(MENU_MAIN));
		
		// Score Menu
		handleClick($('.play-again-btn'), () => {
			setActiveMenu(null);
			resetGame();
		});
		
		handleClick($('.menu-btn--score'), () => setActiveMenu(MENU_MAIN));
		
		
		
		
		////////////////////
		// Button Actions //
		////////////////////
		
		// Main Menu
		handleClick($('.play-normal-btn'), () => {
			setGameMode(GAME_MODE_RANKED);
			setActiveMenu(null);
			resetGame();
		});
		
		handleClick($('.play-casual-btn'), () => {
			setGameMode(GAME_MODE_CASUAL);
			setActiveMenu(null);
			resetGame();
		});
		
		// Pause Menu
		handleClick($('.resume-btn'), () => resumeGame());
		handleClick($('.menu-btn--pause'), () => setActiveMenu(MENU_MAIN));
		
		// Score Menu
		handleClick($('.play-again-btn'), () => {
			setActiveMenu(null);
			resetGame();
		});
		
		handleClick($('.menu-btn--score'), () => setActiveMenu(MENU_MAIN));
		
		
		
		
		
		// actions.js
		// ============================================================================
		// ============================================================================
		
		//////////////////
		// MENU ACTIONS //
		//////////////////
		
		function setActiveMenu(menu) {
			state.menus.active = menu;
			renderMenus();
		}
		
		
		/////////////////
		// HUD ACTIONS //
		/////////////////
		
		function setScore(score) {
			state.game.score = score;
			renderScoreHud();
		}
		
		function incrementScore(inc) {
			if (isInGame()) {
				state.game.score += inc;
				if (state.game.score < 0) {
					state.game.score = 0;
				}
				renderScoreHud();
			}
		}
		
		function setCubeCount(count) {
			state.game.cubeCount = count;
			renderScoreHud();
		}
		
		function incrementCubeCount(inc) {
			if (isInGame()) {
				state.game.cubeCount += inc;
				renderScoreHud();
			}
		}
		
		
		//////////////////
		// GAME ACTIONS //
		//////////////////
		
		function setGameMode(mode) {
			state.game.mode = mode;
		}
		
		function resetGame() {
			resetAllTargets();
			state.game.time = 0;
			resetAllCooldowns();
			setScore(0);
			setCubeCount(0);
			spawnTime = getSpawnDelay();
		}
		
		function pauseGame() {
			isInGame() && setActiveMenu(MENU_PAUSE);
		}
		
		function resumeGame() {
			isPaused() && setActiveMenu(null);
		}
		
		function endGame() {
			handleCanvasPointerUp();
			if (isNewHighScore()) {
				setHighScore(state.game.score);
			}
			setActiveMenu(MENU_SCORE);
		}
		
		
		
		////////////////////////
		// KEYBOARD SHORTCUTS //
		////////////////////////
		
		window.addEventListener('keydown', event => {
			if (event.key === 'p') {
				isPaused() ? resumeGame() : pauseGame();
			}
		});
		
		
		
		
		
		
		// tick.js
		// ============================================================================
		// ============================================================================
		
		
		let spawnTime = 0;
		const maxSpawnX = 450;
		const pointerDelta = { x: 0, y: 0 };
		const pointerDeltaScaled = { x: 0, y: 0 };
		
		// Temp slowmo state. Should be relocated once this stabilizes.
		const slowmoDuration = 1500;
		let slowmoRemaining = 0;
		let spawnExtra = 0;
		const spawnExtraDelay = 300;
		let targetSpeed = 1;
		
		
		function tick(width, height, simTime, simSpeed, lag) {
			PERF_START('frame');
			PERF_START('tick');
		
			state.game.time += simTime;
		
			if (slowmoRemaining > 0) {
				slowmoRemaining -= simTime;
				if (slowmoRemaining < 0) {
					slowmoRemaining = 0;
				}
				targetSpeed = pointerIsDown ? 0.075 : 0.3;
			} else {
				const menuPointerDown = isMenuVisible() && pointerIsDown;
				targetSpeed = menuPointerDown ? 0.025 : 1;
			}
		
			renderSlowmoStatus(slowmoRemaining / slowmoDuration);
		
			gameSpeed += (targetSpeed - gameSpeed) / 22 * lag;
			gameSpeed = clamp(gameSpeed, 0, 1);
		
			const centerX = width / 2;
			const centerY = height / 2;
		
			const simAirDrag = 1 - (airDrag * simSpeed);
			const simAirDragSpark = 1 - (airDragSpark * simSpeed);
		
			// Pointer Tracking
			// -------------------
		
			// Compute speed and x/y deltas.
			// There is also a "scaled" variant taking game speed into account. This serves two purposes:
			//  - Lag won't create large spikes in speed/deltas
			//  - In slow mo, speed is increased proportionately to match "reality". Without this boost,
			//    it feels like your actions are dampened in slow mo.
			const forceMultiplier = 1 / (simSpeed * 0.75 + 0.25);
			pointerDelta.x = 0;
			pointerDelta.y = 0;
			pointerDeltaScaled.x = 0;
			pointerDeltaScaled.y = 0;
			const lastPointer = touchPoints[touchPoints.length - 1];
		
			if (pointerIsDown && lastPointer && !lastPointer.touchBreak) {
				pointerDelta.x = (pointerScene.x - lastPointer.x);
				pointerDelta.y = (pointerScene.y - lastPointer.y);
				pointerDeltaScaled.x = pointerDelta.x * forceMultiplier;
				pointerDeltaScaled.y = pointerDelta.y * forceMultiplier;
			}
			const pointerSpeed = Math.hypot(pointerDelta.x, pointerDelta.y);
			const pointerSpeedScaled = pointerSpeed * forceMultiplier;
		
			// Track points for later calculations, including drawing trail.
			touchPoints.forEach(p => p.life -= simTime);
		
			if (pointerIsDown) {
				touchPoints.push({
					x: pointerScene.x,
					y: pointerScene.y,
					life: touchPointLife
				});
			}
		
			while (touchPoints[0] && touchPoints[0].life <= 0) {
				touchPoints.shift();
			}
		
		
			// Entity Manipulation
			// --------------------
			PERF_START('entities');
		
			// Spawn targets
			spawnTime -= simTime;
			if (spawnTime <= 0) {
				if (spawnExtra > 0) {
					spawnExtra--;
					spawnTime = spawnExtraDelay;
				} else {
					spawnTime = getSpawnDelay();
				}
				const target = getTarget();
				const spawnRadius = Math.min(centerX * 0.8, maxSpawnX);
				target.x = (Math.random() * spawnRadius * 2 - spawnRadius);
				target.y = centerY + targetHitRadius * 2;
				target.z = (Math.random() * targetRadius*2 - targetRadius);
				target.xD = Math.random() * (target.x * -2 / 120);
				target.yD = -20;
				targets.push(target);
			}
		
			// Animate targets and remove when offscreen
			const leftBound = -centerX + targetRadius;
			const rightBound = centerX - targetRadius;
			const ceiling = -centerY - 120;
			const boundDamping = 0.4;
		
			targetLoop:
			for (let i = targets.length - 1; i >= 0; i--) {
				const target = targets[i];
				target.x += target.xD * simSpeed;
				target.y += target.yD * simSpeed;
		
				if (target.y < ceiling) {
					target.y = ceiling;
					target.yD = 0;
				}
		
				if (target.x < leftBound) {
					target.x = leftBound;
					target.xD *= -boundDamping;
				} else if (target.x > rightBound) {
					target.x = rightBound;
					target.xD *= -boundDamping;
				}
		
				if (target.z < backboardZ) {
					target.z = backboardZ;
					target.zD *= -boundDamping;
				}
		
				target.yD += gravity * simSpeed;
				target.rotateX += target.rotateXD * simSpeed;
				target.rotateY += target.rotateYD * simSpeed;
				target.rotateZ += target.rotateZD * simSpeed;
				target.transform();
				target.project();
		
				// Remove if offscreen
				if (target.y > centerY + targetHitRadius * 2) {
					targets.splice(i, 1);
					returnTarget(target);
					if (isInGame()) {
						if (isCasualGame()) {
							incrementScore(-25);
						} else {
							endGame();
						}
					}
					continue;
				}
		
		
				// If pointer is moving really fast, we want to hittest multiple points along the path.
				// We can't use scaled pointer speed to determine this, since we care about actual screen
				// distance covered.
				const hitTestCount = Math.ceil(pointerSpeed / targetRadius * 2);
				// Start loop at `1` and use `<=` check, so we skip 0% and end up at 100%.
				// This omits the previous point position, and includes the most recent.
				for (let ii=1; ii<=hitTestCount; ii++) {
					const percent = 1 - (ii / hitTestCount);
					const hitX = pointerScene.x - pointerDelta.x * percent;
					const hitY = pointerScene.y - pointerDelta.y * percent;
					const distance = Math.hypot(
						hitX - target.projected.x,
						hitY - target.projected.y
					);
		
					if (distance <= targetHitRadius) {
						// Hit! (though we don't want to allow hits on multiple sequential frames)
						if (!target.hit) {
							target.hit = true;
		
							target.xD += pointerDeltaScaled.x * hitDampening;
							target.yD += pointerDeltaScaled.y * hitDampening;
							target.rotateXD += pointerDeltaScaled.y * 0.001;
							target.rotateYD += pointerDeltaScaled.x * 0.001;
		
							const sparkSpeed = 7 + pointerSpeedScaled * 0.125;
		
							if (pointerSpeedScaled > minPointerSpeed) {
								target.health--;
								incrementScore(10);
		
								if (target.health <= 0) {
									incrementCubeCount(1);
									createBurst(target, forceMultiplier);
									sparkBurst(hitX, hitY, 8, sparkSpeed);
									if (target.wireframe) {
										slowmoRemaining = slowmoDuration;
										spawnTime = 0;
										spawnExtra = 2;
									}
									targets.splice(i, 1);
									returnTarget(target);
								} else {
									sparkBurst(hitX, hitY, 8, sparkSpeed);
									glueShedSparks(target);
									updateTargetHealth(target, 0);
								}
							} else {
								incrementScore(5);
								sparkBurst(hitX, hitY, 3, sparkSpeed);
							}
						}
						// Break the current loop and continue the outer loop.
						// This skips to processing the next target.
						continue targetLoop;
					}
				}
		
				// This code will only run if target hasn't been "hit".
				target.hit = false;
			}
		
			// Animate fragments and remove when offscreen.
			const fragBackboardZ = backboardZ + fragRadius;
			// Allow fragments to move off-screen to sides for a while, since shadows are still visible.
			const fragLeftBound = -width;
			const fragRightBound = width;
		
			for (let i = frags.length - 1; i >= 0; i--) {
				const frag = frags[i];
				frag.x += frag.xD * simSpeed;
				frag.y += frag.yD * simSpeed;
				frag.z += frag.zD * simSpeed;
		
				frag.xD *= simAirDrag;
				frag.yD *= simAirDrag;
				frag.zD *= simAirDrag;
		
				if (frag.y < ceiling) {
					frag.y = ceiling;
					frag.yD = 0;
				}
		
				if (frag.z < fragBackboardZ) {
					frag.z = fragBackboardZ;
					frag.zD *= -boundDamping;
				}
		
				frag.yD += gravity * simSpeed;
				frag.rotateX += frag.rotateXD * simSpeed;
				frag.rotateY += frag.rotateYD * simSpeed;
				frag.rotateZ += frag.rotateZD * simSpeed;
				frag.transform();
				frag.project();
		
				// Removal conditions
				if (
					// Bottom of screen
					frag.projected.y > centerY + targetHitRadius ||
					// Sides of screen
					frag.projected.x < fragLeftBound ||
					frag.projected.x > fragRightBound ||
					// Too close to camera
					frag.z > cameraFadeEndZ
				) {
					frags.splice(i, 1);
					returnFrag(frag);
					continue;
				}
			}
		
			// 2D sparks
			for (let i = sparks.length - 1; i >= 0; i--) {
				const spark = sparks[i];
				spark.life -= simTime;
				if (spark.life <= 0) {
					sparks.splice(i, 1);
					returnSpark(spark);
					continue;
				}
				spark.x += spark.xD * simSpeed;
				spark.y += spark.yD * simSpeed;
				spark.xD *= simAirDragSpark;
				spark.yD *= simAirDragSpark;
				spark.yD += gravity * simSpeed;
			}
		
			PERF_END('entities');
		
			// 3D transforms
			// -------------------
		
			PERF_START('3D');
		
			// Aggregate all scene vertices/polys
			allVertices.length = 0;
			allPolys.length = 0;
			allShadowVertices.length = 0;
			allShadowPolys.length = 0;
			targets.forEach(entity => {
				allVertices.push(...entity.vertices);
				allPolys.push(...entity.polys);
				allShadowVertices.push(...entity.shadowVertices);
				allShadowPolys.push(...entity.shadowPolys);
			});
		
			frags.forEach(entity => {
				allVertices.push(...entity.vertices);
				allPolys.push(...entity.polys);
				allShadowVertices.push(...entity.shadowVertices);
				allShadowPolys.push(...entity.shadowPolys);
			});
		
			// Scene calculations/transformations
			allPolys.forEach(p => computePolyNormal(p, 'normalWorld'));
			allPolys.forEach(computePolyDepth);
			allPolys.sort((a, b) => b.depth - a.depth);
		
			// Perspective projection
			allVertices.forEach(projectVertex);
		
			allPolys.forEach(p => computePolyNormal(p, 'normalCamera'));
		
			PERF_END('3D');
		
			PERF_START('shadows');
		
			// Rotate shadow vertices to light source perspective
			transformVertices(
				allShadowVertices,
				allShadowVertices,
				0, 0, 0,
				TAU/8, 0, 0,
				1, 1, 1
			);
		
			allShadowPolys.forEach(p => computePolyNormal(p, 'normalWorld'));
		
			const shadowDistanceMult = Math.hypot(1, 1);
			const shadowVerticesLength = allShadowVertices.length;
			for (let i=0; i<shadowVerticesLength; i++) {
				const distance = allVertices[i].z - backboardZ;
				allShadowVertices[i].z -= shadowDistanceMult * distance;
			}
			transformVertices(
				allShadowVertices,
				allShadowVertices,
				0, 0, 0,
				-TAU/8, 0, 0,
				1, 1, 1
			);
			allShadowVertices.forEach(projectVertex);
		
			PERF_END('shadows');
		
			PERF_END('tick');
		}
		
		
		
		
		
		// draw.js
		// ============================================================================
		// ============================================================================
		
		function draw(ctx, width, height, viewScale) {
			PERF_START('draw');
		
			const halfW = width / 2;
			const halfH = height / 2;
		
		
			// 3D Polys
			// ---------------
			ctx.lineJoin = 'bevel';
		
			PERF_START('drawShadows');
			ctx.fillStyle = shadowColor;
			ctx.strokeStyle = shadowColor;
			allShadowPolys.forEach(p => {
				if (p.wireframe) {
					ctx.lineWidth = 2;
					ctx.beginPath();
					const { vertices } = p;
					const vCount = vertices.length;
					const firstV = vertices[0];
					ctx.moveTo(firstV.x, firstV.y);
					for (let i=1; i<vCount; i++) {
						const v = vertices[i];
						ctx.lineTo(v.x, v.y);
					}
					ctx.closePath();
					ctx.stroke();
				} else {
					ctx.beginPath();
					const { vertices } = p;
					const vCount = vertices.length;
					const firstV = vertices[0];
					ctx.moveTo(firstV.x, firstV.y);
					for (let i=1; i<vCount; i++) {
						const v = vertices[i];
						ctx.lineTo(v.x, v.y);
					}
					ctx.closePath();
					ctx.fill();
				}
			});
			PERF_END('drawShadows');
		
			PERF_START('drawPolys');
		
			allPolys.forEach(p => {
				if (!p.wireframe && p.normalCamera.z < 0) return;
		
				if (p.strokeWidth !== 0) {
					ctx.lineWidth = p.normalCamera.z < 0 ? p.strokeWidth * 0.5 : p.strokeWidth;
					ctx.strokeStyle = p.normalCamera.z < 0 ? p.strokeColorDark : p.strokeColor;
				}
		
				const { vertices } = p;
				const lastV = vertices[vertices.length - 1];
				const fadeOut = p.middle.z > cameraFadeStartZ;
		
				if (!p.wireframe) {
					const normalLight = p.normalWorld.y * 0.5 + p.normalWorld.z * -0.5;
					const lightness = normalLight > 0
						? 0.1
						: ((normalLight ** 32 - normalLight) / 2) * 0.9 + 0.1;
					ctx.fillStyle = shadeColor(p.color, lightness);
				}
		
				// Fade out polys close to camera. `globalAlpha` must be reset later.
				if (fadeOut) {
					// If polygon gets really close to camera (outside `cameraFadeRange`) the alpha
					// can go negative, which has the appearance of alpha = 1. So, we'll clamp it at 0.
					ctx.globalAlpha = Math.max(0, 1 - (p.middle.z - cameraFadeStartZ) / cameraFadeRange);
				}
		
				ctx.beginPath();
				ctx.moveTo(lastV.x, lastV.y);
				for (let v of vertices) {
					ctx.lineTo(v.x, v.y);
				}
		
				if (!p.wireframe) {
					ctx.fill();
				}
				if (p.strokeWidth !== 0) {
					ctx.stroke();
				}
		
				if (fadeOut) {
					ctx.globalAlpha = 1;
				}
			});
			PERF_END('drawPolys');
		
		
			PERF_START('draw2D');
		
			// 2D Sparks
			// ---------------
			ctx.strokeStyle = sparkColor;
			ctx.lineWidth = sparkThickness;
			ctx.beginPath();
			sparks.forEach(spark => {
				ctx.moveTo(spark.x, spark.y);
				// Shrink sparks to zero length as they die.
				// Speed up shrinking as life approaches 0 (root curve).
				// Note that sparks already get smaller over time as their speed slows
				// down from damping. So this is like a double scale down. To counter this
				// a bit and keep the sparks larger for longer, we'll also increase the scale
				// a bit after applying the root curve.
				const scale = (spark.life / spark.maxLife) ** 0.5 * 1.5;
				ctx.lineTo(spark.x - spark.xD*scale, spark.y - spark.yD*scale);
		
			});
			ctx.stroke();
		
		
			// Touch Strokes
			// ---------------
		
			ctx.strokeStyle = touchTrailColor;
			const touchPointCount = touchPoints.length;
			for (let i=1; i<touchPointCount; i++) {
				const current = touchPoints[i];
				const prev = touchPoints[i-1];
				if (current.touchBreak || prev.touchBreak) {
					continue;
				}
				const scale = current.life / touchPointLife;
				ctx.lineWidth = scale * touchTrailThickness;
				ctx.beginPath();
				ctx.moveTo(prev.x, prev.y);
				ctx.lineTo(current.x, current.y);
				ctx.stroke();
			}
		
			PERF_END('draw2D');
		
			PERF_END('draw');
			PERF_END('frame');
		
			// Display performance updates.
			PERF_UPDATE();
		}
		
		
		
		
		
		// canvas.js
		// ============================================================================
		// ============================================================================
		
		function setupCanvases() {
			const ctx = canvas.getContext('2d');
			// devicePixelRatio alias
			const dpr = window.devicePixelRatio || 1;
			// View will be scaled so objects appear sized similarly on all screen sizes.
			let viewScale;
			// Dimensions (taking viewScale into account!)
			let width, height;
		
			function handleResize() {
				const w = window.innerWidth;
				const h = window.innerHeight;
				viewScale = h / 1000;
				width = w / viewScale;
				height = h / viewScale;
				canvas.width = w * dpr;
				canvas.height = h * dpr;
				canvas.style.width = w + 'px';
				canvas.style.height = h + 'px';
			}
		
			// Set initial size
			handleResize();
			// resize fullscreen canvas
			window.addEventListener('resize', handleResize);
		
		
			// Run game loop
			let lastTimestamp = 0;
			function frameHandler(timestamp) {
				let frameTime = timestamp - lastTimestamp;
				lastTimestamp = timestamp;
		
				// always queue another frame
				raf();
		
				// If game is paused, we'll still track frameTime (above) but all other
				// game logic and drawing can be avoided.
				if (isPaused()) return;
		
				// make sure negative time isn't reported (first frame can be whacky)
				if (frameTime < 0) {
					frameTime = 17;
				}
				// - cap minimum framerate to 15fps[~68ms] (assuming 60fps[~17ms] as 'normal')
				else if (frameTime > 68) {
					frameTime = 68;
				}
		
				const halfW = width / 2;
				const halfH = height / 2;
		
				// Convert pointer position from screen to scene coords.
				pointerScene.x = pointerScreen.x / viewScale - halfW;
				pointerScene.y = pointerScreen.y / viewScale - halfH;
		
				const lag = frameTime / 16.6667;
				const simTime = gameSpeed * frameTime;
				const simSpeed = gameSpeed * lag;
				tick(width, height, simTime, simSpeed, lag);
		
				// Auto clear canvas
				ctx.clearRect(0, 0, canvas.width, canvas.height);
				// Auto scale drawing for high res displays, and incorporate `viewScale`.
				// Also shift canvas so (0, 0) is the middle of the screen.
				// This just works with 3D perspective projection.
				const drawScale = dpr * viewScale;
				ctx.scale(drawScale, drawScale);
				ctx.translate(halfW, halfH);
				draw(ctx, width, height, viewScale);
				ctx.setTransform(1, 0, 0, 1, 0, 0);
			}
			const raf = () => requestAnimationFrame(frameHandler);
			// Start loop
			raf();
		}
		
		
		
		
		
		// interaction.js
		// ============================================================================
		// ============================================================================
		
		// Interaction
		// -----------------------------
		
		function handleCanvasPointerDown(x, y) {
			if (!pointerIsDown) {
				pointerIsDown = true;
				pointerScreen.x = x;
				pointerScreen.y = y;
				// On when menus are open, point down/up toggles an interactive mode.
				// We just need to rerender the menu system for it to respond.
				if (isMenuVisible()) renderMenus();
			}
		}
		
		function handleCanvasPointerUp() {
			if (pointerIsDown) {
				pointerIsDown = false;
				touchPoints.push({
					touchBreak: true,
					life: touchPointLife
				});
				// On when menus are open, point down/up toggles an interactive mode.
				// We just need to rerender the menu system for it to respond.
				if (isMenuVisible()) renderMenus();
			}
		}
		
		function handleCanvasPointerMove(x, y) {
			if (pointerIsDown) {
				pointerScreen.x = x;
				pointerScreen.y = y;
			}
		}
		
		
		// Use pointer events if available, otherwise fallback to touch events (for iOS).
		if ('PointerEvent' in window) {
			canvas.addEventListener('pointerdown', event => {
				event.isPrimary && handleCanvasPointerDown(event.clientX, event.clientY);
			});
		
			canvas.addEventListener('pointerup', event => {
				event.isPrimary && handleCanvasPointerUp();
			});
		
			canvas.addEventListener('pointermove', event => {
				event.isPrimary && handleCanvasPointerMove(event.clientX, event.clientY);
			});
		
			// We also need to know if the mouse leaves the page. For this game, it's best if that
			// cancels a swipe, so essentially acts as a "mouseup" event.
			document.body.addEventListener('mouseleave', handleCanvasPointerUp);
		} else {
			let activeTouchId = null;
			canvas.addEventListener('touchstart', event => {
				if (!pointerIsDown) {
					const touch = event.changedTouches[0];
					activeTouchId = touch.identifier;
					handleCanvasPointerDown(touch.clientX, touch.clientY);
				}
			});
			canvas.addEventListener('touchend', event => {
				for (let touch of event.changedTouches) {
					if (touch.identifier === activeTouchId) {
						handleCanvasPointerUp();
						break;
					}
				}
			});
			canvas.addEventListener('touchmove', event => {
				for (let touch of event.changedTouches) {
					if (touch.identifier === activeTouchId) {
						handleCanvasPointerMove(touch.clientX, touch.clientY);
						event.preventDefault();
						break;
					}
				}
			}, { passive: false });
		}
		
		
		
		
		
		// index.js
		// ============================================================================
		// ============================================================================
		
		setupCanvases();